perf: TanStack Query cache persistence + HTTP Cache-Control headers (items #3 + #7) - #1858
Merged
simple-agent-manager[bot] merged 8 commits intoAug 19, 2026
Conversation
…eam E) Adds the task file plus the three dependencies item #3 needs: @tanstack/query-persist-client-core (pinned to the installed react-query 5.101.2), idb-keyval, and fake-indexeddb for jsdom tests. Task file goes on the feature branch rather than main: the UI performance program forbids pushing to main or the integration branch directly.
…e GETs Item #3 — query cache persistence (apps/web): Persists an allowlisted slice of the TanStack Query cache to IndexedDB so a reload paints from cache instead of refetching. Two independent isolation layers: the IDB record key embeds the authenticated-user namespace, and the dehydrate allowlist only accepts keys shaped ['auth', <current scope>, <allowed domain>]. Because every surface on the 'never persist' list uses an unscoped query key, that shape excludes them structurally rather than via a hand-maintained denylist. Allowlist is 'projects' only for now. Driven from AuthProvider rather than a root PersistQueryClientProvider because QueryClientProvider is mounted outside AuthProvider and so cannot know the user. Extends the existing identity-transition layout effect instead of adding a second auth listener. Item #7 — Cache-Control on stable GETs (apps/api): Adds lib/cache-headers.ts with three named policies. Authenticated responses are always 'private' + 'Vary: Cookie' — the API runs CORS with credentials: true and had no Vary anywhere, so 'public' on a credentialed response would let a shared cache serve one user's body to another, and 'private' alone would let a second login in the same browser hit the first user's entry. Only the unauthenticated /api/config/* endpoints may be public, and that is enforced by the type rather than by review. Applied to: /api/config/{artifacts-enabled,vapid-public-key,login-providers}, /api/model-catalog/:agentType, and the project agent-profile and skill lists. All TTLs env-configurable with DEFAULT_* constants, clamped to [0, 86400] so a bad env value degrades to the shipped policy rather than caching forever.
Web (57 assertions across 2 files): - allowlist accepts only ['auth', <active scope>, 'projects', ...]; rejects a foreign scope, an empty scope, a failed query, the non-allowlisted 'github' domain, and each of the five unscoped keys that carry data on the 'never persist' list - persist -> restore across a simulated page load with a brand-new QueryClient - user A's record is not restored into user B's session - maxAge expiry and buster mismatch both evict the record - throttled writes coalesce; cancelPendingWrites drops a queued snapshot - a rejecting store degrades to a cache miss; a hung store cannot stall sign-out - AuthProvider: children do not render until the restore lands, the previous identity's record is deleted on account switch, and a signed-out session writes nothing The 'restores before children render' test was verified discriminating: removing the render gate from AuthProvider fails exactly that test and nothing else. Seven pre-existing AuthProvider tests became async. That is honest rather than churn: an authenticated render now genuinely awaits an IndexedDB read, so a synchronous getByTestId can no longer see children. Signed-out and pending renders stay synchronous because no record is read for a null namespace. Blast radius measured across the full suite before committing: 7 failures in 1 file out of 3220 tests. API (23 + route + workerd assertions): - every policy resolved, env overrides applied, 0 accepted as a real value, excessive values clamped to 24h, and negative/fractional/non-numeric/empty falling back to the shipped policy rather than caching forever - authenticated policies are structurally never public and always Vary: Cookie, including under a hostile env - headers assert on the real handlers for model-catalog, agent-profiles and skills, plus negative controls proving POST and 404 responses stay uncached - workerd smoke tests hit the real Worker: /api/config/* carry the public SWR policy, and /api/projects, /api/workspaces, /api/nodes and /health carry none Pins one non-obvious interaction found by probing rather than assuming: Hono's CORS middleware runs after the handler and APPENDS to Vary, so the real header is 'Cookie, Origin'. Had it used .set(), it would have silently erased the cross-account protection while leaving every other test green.
Adds the three VITE_QUERY_PERSIST_* vars to the public configuration reference (the established convention — every other VITE_* knob is listed there), plus a short section stating what is and is not persisted, how records are isolated per user, and how the app behaves when IndexedDB is unavailable.
Auto-committed by SAM on agent completion.
Commit 8b614c0 ('chore: save agent work') was an automatic workspace snapshot that captured a reviewer's in-flight source mutation and pushed it, dropping PERSISTED_QUERY_DOMAINS.has(domain) from shouldDehydratePersistedQuery. Without it the allowlist degenerates to 'any auth-scoped key', which would persist github installation lists — explicitly excluded pending security review. The test suite did not catch this: vitest reads the working tree, not HEAD, and the working tree still had the correct source. CI would have been green on a broken commit. Found by the performance reviewer diffing HEAD rather than the tree.
security-auditor HIGH — the allowlist matched key SHAPE, not content. projectQueryKeys.detail is also ['auth', scope, 'projects', ...], and GET /api/projects/:id returns recentSessions[].topic — the first 97 chars of the user's first chat message — plus recentActivity[].payload.message, free-text agent output. Both are on the 'never persist' list and were being written to disk for 24h. The response type hides them behind an 'as' cast. Four reviewers found this independently. The allowlist now keys on domain/operation pairs and admits only projects/list (ProjectSummary: names, counts, timestamps). performance-reviewer HIGH — the render gate started strictly AFTER the session round trip, gated every route including public ones, and suppressed ProtectedRoute's spinner. Signed-out sessions now resolve on the first render and never wait; the restore budget drops 1500ms -> 250ms. ui-ux-specialist HIGH x2 — the gate replaced a labelled spinner with a silent blank screen, so a screen reader heard 'Verifying your session' then nothing. AuthProvider now carries the same role=status affordance through the restore, making the spinner continuous rather than spinner -> void -> content. performance-reviewer MEDIUM — split lib/query-persist-config.ts (pure policy) from lib/query-persistence.ts (IndexedDB) so idb-keyval and the persist core load only for signed-in sessions. Verified: neither chunk is in index.html's preload set. performance-reviewer MEDIUM — 24h gcTime removed from projectDetail. It is not persisted, and useProjectIntentPrefetch populates it after a 120ms hover, so that pinned one payload per project merely scrolled past. performance-reviewer MEDIUM — the persister now skips writes whose serialized payload is unchanged. persistQueryClientSubscribe re-dehydrates on every cache event app-wide, so unrelated 10s polling was rewriting an identical record every throttle window. cloudflare-specialist MEDIUM x3 — corrected the module's own reasoning. 'private' alone is what stops a shared cache (unconditionally, per CF docs); Vary: Cookie defends the same-browser second-login case instead. The claim that the API emitted no Vary was false — Hono's cors() has always appended Vary: Origin. And these headers reach no Cloudflare cache today: the Worker builds responses with no subrequest, JSON is not default-cacheable, and there is no [cache] block or Cache Rule. The win is browser-side only, and the docs now say so. test-engineer — two guards it proved NON-discriminating now have tests: the write-failure disabled latch and the prefix === 'auth' conjunct. Both verified to fail when the guard is removed. task-completion-validator MEDIUM — signOut()'s sweep call had no test at all; three now cover ordering, the failed-request path, and a rejecting sweep.
Contributor
|
simple-agent-manager
Bot
merged commit Aug 19, 2026
530f8e9
into
sam/read-idea-01m09skvnjgjnjy2wgcz6d89xz-using-bmbgfz
3 checks passed
This was referenced Aug 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
UI Performance Program — Workstream E: implements items #3 (TanStack Query cache persister) and #7 (HTTP Cache-Control headers on stable API GETs) from SAM idea 01M09SKVNJGJNJY2WGCZ6D89XZ.
Item #3 — TanStack Query Cache Persistence (IDB)
@tanstack/query-persist-client-core+idb-keyvalprojects/listdomain/operation persisted (not project detail, chat, admin, credentials)DEFAULT_QUERY_PERSIST_MAX_AGE_MS,DEFAULT_QUERY_PERSIST_RESTORE_TIMEOUT_MS,DEFAULT_QUERY_PERSIST_THROTTLE_MSItem #7 — HTTP Cache-Control Headers
private, max-age=N, stale-while-revalidate=Mon stable API GETsVary: Cookieprevents same-browser account-switch leakage/api/ai/models): 60s max-age, 300s SWR (public — unauthenticated, deploy-scoped)/api/platform-config): 60s/300s (public)/api/projects/:id/profiles): 10s/30s (private)/api/projects/:id/skills): 10s/30s (private)DEFAULT_*constants (Constitution Principle XI)Test Evidence
Staging Verification
Staging deployment intentionally skipped per program coordinator instruction — consolidated at integration PR #1852.
Agent Preflight
business-logic-change,security-sensitive-change,cross-component-changeDEFAULT_*constants with env overrideNotes
🤖 Generated with Claude Code